CalcArray Documentation

How the code of the 2048 Power Compendium works

Part 1: Introduction to CalcArrays

Prelude

The 2048 Power Compendium is a collection of 100 different gameplay variants of 2048, most of which were made by me (but some came from elsewhere or were suggested by players, though as of them they were all still coded, or at least re-implemented, by me). Several of the people on my Discord server have taken an interest in working with the code of the game (which is open-source and can be found on GitHub), so I figured I'd write a detailed explanation of how it works to aid them.

This blog post assumes you have some programming knowledge - the 2048 Power Compendium is coded in JavaScript, though by the nature of programming languages, if you know some other programming language you'll probably still be able to read this, seeing as JavaScript itself isn't what this post is about per se.

On the surface, the 2048 Power Compendium appears to be a collection of 2048 variants, but internally it's more like an engine for loading and running 2048 variants, which has 100 of them set to be loaded already. The code for loading a variant looks like this:

If the variants were being run directly in JavaScript, you'd expect something like if {Grid[y][x] == Grid[y][x + 1]} somewhere in there to check whether two tiles are equal. But instead of a bunch of if statements, there's a bunch of arrays. MergeRules and the last entry of TileTypes seem especially curious - what's up with "@This 0" and those mathematical expressions written as arrays?

That is what this blog post will explain.

(Before we begin, here's a warning: there are secrets hidden in the 2048 Power Compendium, and this blog post will give some spoilers for a couple of them. If you wish to find those secrets yourself, I recommend waiting to read this blog post until you do. If you do not know yet whether you have found all of the secrets, then you have not.)

What is a CalcArray?

The code that the 2048 Power Compendium runs to do things like merges is not written directly in JavaScript. It's written in CalcArrays, a sort of programming language within the Power Compendium's code.

In their most basic form, CalcArrays evaluate simple math operations. For example, [4, "+", 5] will evaluate to 9. If you're using the JS console on the Power Compendium website and wish to try out CalcArrays as we go, use the function CalcArray(); for example, CalcArray([4, "+", 5]); will return 9.

CalcArrays have no concept of an order of operations, they simply evaluate from left to right. For example, when evaluating [3, "+", 2, "*", 4], the addition is done first since it comes first in the array, so that array simplifies to [5, "*", 4], which returns 20.

There is, however, an equivalent to parentheses: nested arrays. An array inside a CalcArray will itself be evaluated as a CalcArray, so [3, "+", [2, "*", 4]] becomes [3, "+", 8], which returns 11.

Generally, when using an operator, the order of the arguments is argument 1, operator, argument 2, where often argument 1 is the result of everything up to that point. If an operator only has one argument, then it's just argument 1, operator. If an operator has more than two arguments, it's argument 1, operator, argument 2, argument 3, argument 4... up until the last one. All operators, with the exception of some special things that are moreso control tools than operators, have a fixed number of arguments.

CalcArrays can do a lot more than basic arithmetic, and numbers aren't the only data types that CalcArrays work with. They can also work with a few other types, such as strings, booleans, and bigints. Type conversion is automatic: each operator has a type it works with (with a few exceptions), and when it's time to apply that operator, its arguments will be automatically converted to that type before it's applied. CalcArrays can also do things like loops, conditionals, variables, and so on. We'll be going over all of those throughout this blog post.

Where and Why are CalcArrays used?

CalcArrays are used in a few places when creating a mode in the Power Compendium:

But why does the Power Compendium have a custom "programming language" for all these instead of just writing them in JavaScript? There are two main reasons:

We'll get to how to use CalcArrays in these places later on, but first we need to discuss how to use them on their own, because even when they're not attached to a tile type, merge rule, or other such structure, they can still be used as a programming language of sorts - at least in the console, anyway.

Part 2: CalcArray Operators

Number Operators

Let's start with the operators for numbers, since those were what CalcArrays were originally designed to work with. Here's a list of the operators that are generally used with numbers. Unless otherwise stated, the arguments to these operators are numbers, and the value they result in is a number.

Comparison Operators

These operators don't have a set type: they will take arguments of any type, so they won't do type conversion before evaluation.

Boolean Operators

These operators take boolean arguments and result in a boolean.

String Operators

The first argument to these operators is a string, but the later argument(s) might not be; unlike with number operators, where every argument was a number, string operators often do things that require some non-string arguments. These operators are generally denoted by having "str_" at the front, to distinguish them from their array counterparts if they have one.

Literal Arrays and Array Operators

Normally, when there's an array inside a CalcArray, that inside array is also interpreted as a CalcArray. If you want to create an actual array as a value to manipulate within a CalcArray, then you have to put "@Literal" at the beginning of that array. For example, putting [1, 5, 9] into a CalcArray will cause it to attempt to evaluate that as a CalcArray (and 5 isn't a valid operator, so it will just return 1), but if you put ["@Literal", 1, 5, 9] into a CalcArray, that will become the array [1, 5, 9] as an array value that can be used within the CalcArray.

Any array inside a literal array is also treated as a literal array; you do not need to put "@Literal" at the starts of the sub-arrays, only at the start of the external literal array. If you want an array inside a literal array to be evaluated as a CalcArray, put "@CalcArray" at the start of the array: for example, if you put ["@Literal", 1, [2, "+", 4], ["@CalcArray", 2, "+", 4]] into a CalcArray, then the value that evaluates to is the array [1, [2, "+", 4], 6].

CalcArrays treat arrays as values, not as objects. This means that, like strings, every CalcArray operation on an array creates a new array rather than mutating the old one, even operators like "arr_push" whose JavaScript equivalents mutate the existing array. This also means that checking arrays for equality doesn't check if they're "the same object", it checks if all their elements are equal. Inequalities still always return false on arrays.

Here are the operators whose first argument is an array. These operators are generally denoted by having "arr_" at the front, to distinguish them from their string counterparts if they have one.

There's a few more advanced array operators that I can't explain yet, because they rely on variables, which I haven't discussed yet, to do their work: "arr_sort", "arr_map", "arr_filter", "arr_reduce", and "arr_reduceRight". These will be discussed later.

BigInt Operators

Putting a BigInt into a CalcArray works differently depending on where you're doing it. If you're using CalcArrays in the Compendium directly (such as messing with the console or actually editing the code to make a mode), then they work just as you'd expect: the BigInt of value 5 is 5n. However, BigInts don't work nicely with JSON's stringify and parse methods, which are used for save codes, so if you're editing a save code, they're entered differently: the BigInt of value 5 is "@BigInt 5". These methods of entering BigInts are mutually exclusive: 5n won't work in a save code, and "@BigInt 5" won't work in the Compendium directly.

BigInt operators are generally the same as number operators, except they have a B on the end to indicate that they're the BigInt versions. The following operators work the same as their number counterparts, the only change is that their arguments and result are BigInts: "+B", "-B", "*B", "%B", "modB", "^B"/"**B", "absB", "signB", "gcdB", "lcmB", "factorialB", "primeB", "expomodB", "roundB" (remember, round has a second argument and it rounds the first argument to the nearest multiple of the second, so rounding isn't useless here), "floorB", "ceilB"/"ceilingB" (truncB currently doesn't exist, though I don't remember why I didn't add it), "bit&B", "bit|B", "bit~B", "bit<<B", "bit>>B", and "bit>>>B", and "rand_bigint" works the same as "rand_int".

"/B" and "logB" also exist. Since the number versions of these operators can return decimals, their results are truncated here (remember, "truncated" means "rounded towards 0"): for example, [14n, "/B", 3n] results in 4n, since 14 / 3 is between 4 and 5. Truncated division is how division normally works with BigInts, so it shouldn't be a surprise that that's how it's done for division and logarithms here. Be careful here: CalcArrays do not have any error handling built in, so if you do something that cause BigInts to throw an error, like dividing by 0 or taking the logarithm of a negative number, the game will crash! (though "logB" will not throw an error when given 0n as its first argument; it will result in -1n instead).

"defaultAbbrevB" exists too. It's assumed that if you're using a BigInt, you care about the exact value of the number rather than just its size, so "defaultAbbrevB" never switches to scientific notation - the BigInt's digits are written out in full (with commas if it has at least five digits), regardless of how many digits there are.

The only number operators that don't have BigInt counterparts at all are the trig functions and "rand_float", since those four operators make no sense without non-whole numbers.

Several of the modes that do more advanced things with their numbers (1762, SQUART, X^Y, DIVE, and so on; basically any mode where the exact values of the numbers, rather than just multiples and products of powers of numbers, are relevant), use BigInts instead of numbers. This means that BigInts have some additional operators that do not have number equivalents:

These operators (aside from "rootB") don't have number versions because it's assumed that if you're in a situation that calls for such an operator, then you're in a situation where you care about the number's precise value, so you'd want to use BigInts anyway. (The only reason numbers even get "prime" is that that operator was added before BigInt support was added to CalcArrays. If that weren't the case, anything to do with primes would be BigInt exclusive, since if you're using numbers rather than BigInts you probably don't care about primes in that situation.)

BigRationals

In v2.1, a new number type was added to the 2048 Power Compendium: BigRational, exact-precision rational numbers, which are stored as a numerator and denominator that are both BigInts. BigRational is a class, so to make a BigRational with value 2/3, you do new BigRational(2n, 3n). You can also just give a single BigInt or number instead of two (if the argument is one number, continued fractions will be used to approximate a non-whole number as a fraction), or even a string such as "2/3", as an argument to the BigRational constructor. BigRationals are objects, but they're treated as if they're values: all of their methods create new BigRationals (so they do not mutate the existing ones). All BigRational operations automatically simplify their fractions, so new BigRational(1n, 3n).plus(new BigRational(1n, 6n)) will return a BigRational with value 1/2, i.e. with a numerator of 1 and a denominator of 2. BigRationals also support the three non-finite floating point values: 1/0 is Infinity, -1/0 is -Infinity (Infinity and -Infinity's numerators simplify to 1 and -1), and 0/0 is NaN.

As with BigInts, you have to make BigRationals differently if you're editing a save code, since stringify doesn't preserve function methods: in a save code, "@BigRational 2 3" will make a BigRational with value 2/3.

Like how BigInt operators have B on the end, BigRational operators have BR on the end. The following operators are equivalents to number/BigInt operators that work as expected: "+BR", "-BR", "*BR", "/BR", "modBR" ("%BR" doesn't exist - since BigRational is a class I created, I only bothered to add the floored modulo since that's in my opinion the correct one, not the truncated modulo), "absBR", "signBR", "roundBR", "floorBR", "ceilBR"/"ceilingBR" (again, truncation isn't included here), "gcdBR", "lcmBR", "roundBR", "expomodBR" (unlike with numbers and BigInts, here the exponent can be negative: 3/8 has -3 factors of 2 in it, for example), and "perfectPowerFormBR".

"defaultAbbrevBR" writes BigRationals as mixed numbers.

There are also some BigRational operators that either don't have number or BigInt equivalents, or work differently than those equivalents:

BigRational was made so far as it was needed for the Power Compendium's use. As such, it does not include methods like roots, logs, or trig functions at this time. Those operations don't return rational numbers - numbers and BigInts can have versions of them because they have limited "granularity" (i.e. there is a nonzero step size between each possible value), but BigRationals can get as precise as needed, so a root, log, or trig function would never reach a sufficiently exact answer. Such an operator would require an extra argument specifying a level of precision to stop the algorithm at. I'll probably extend the BigRational class to include these operators someday, but for now they are not included.

BigRationals are used in a few places in the Power Compendium's code - places where both non-integer values and exact precision are needed. They're most visibly used in the Partial Absorb variant of the mode 180, but they're also used for the colors of tiles in mod 27, calculating the ratio between tiles in 3385, and a few other places.

GaussianBigInts

This is the other number type class that the Compendium uses that's not a native JS type. GaussianBigInts were actually added before BigRationals, in v1.5, but I chose to mention BigRationals first since they're easier to understand and are a better example of these classes. A GaussianBigInt represents a Gaussian integer, a complex number of the form a+bi for integers a and b. As with BigRationals, you'd make a GaussianBigInt with value 2+3i via new GaussianBigInt(2n, 3n), unless you're in a save code, in which case you'd do "@GaussianBigInt 2 3" instead.

The complex numbers aren't ordered, so inequalities don't work on them: inequalities on GaussianBigInts will always return false, max/min on GaussianBigInts will throw an error.

GaussianBigInt operators have GB on the end. The following operators are equivalents to operators from the other number classes and work as expected: "+GB", "-GB", "*GB", "modGB", "negGB", "gcdGB", "lcmGB", "expomodGB", and "defaultAbbrevGB".

The kind of number that GaussianBigInts are representing is more "exotic" than the kind that BigRationals are representing, so there are more operators here that work differently:

You may be wondering where in the Power Compendium this class is used. It's not used in 16+16i, those tiles are represented in a manner similar to other "powers of n" modes. So where is it used, then? Whereas BigRationals are used in several places, GaussianBigInts are only used in one place... and I'll leave it to you to figure out where.

Other Operators

And now for the operators that don't fall under one of the above categories.

First, there's the type conversion operators, which all take 1 argument: "Number", "String", "Boolean", "Array", "BigInt", "GaussianBigInt", and "BigRational", which each convert the argument into their respective type. "Number" on a GaussianBigInt will return 0 unless the GaussianBigInt is pure real, while "Number" on a BigRational works properly (i.e. converting the BigRational 3/2 to a number gives the number 1.5). Unlike in JavaScript, arrays converted to strings in CalcArrays will include the brackets on the edges of the array. A GaussianBigInt converted into a string will be written as "a+bi" or "a-bi" where a and b are the component numbers, A BigRational converted into a string will be written as a (potentially improper) fraction, unless the denominator is 1 (in which case it's written as a whole number) or 0 (in which case it's written as "Infinity", "-Infinity", or "NaN"). "Boolean" on a GaussianBigInt or BigRational will always result in true, since they're technically objects. "Array" results in a one-element array where the element is the argument (unlike the implicit type conversion, the "Array" operator does this even if that element was itself an array). "BigInt" will, if the value can't be converted to a BigInt, try rounding it first, and if it still fails (such as if you're converting a string, not a number, and that string can't become a BigInt itself), will default to 0. Likewise, "GaussianBigInt" will default to 0+0i if the argument can't be converted correctly, while "BigRational" will default to 0/0 (NaN) if the argument can't be converted correctly.

There's also "typeof", which takes 1 argument and results in the type of that argument as a string: "number", "string", "boolean", "array", "bigint", "gaussianbigint", or "bigrational". (CalcArrays don't have tools built in to handle the value undefined, so it's recommended to avoid writing CalcArrays that could get undefined involved, but if it shows up anyway, "typeof" will still return "undefined" on it.)

There's a couple simple ones that just return one of the arguments, ignoring the other one:

"announce", "output", and "console.log" all display their second argument somehow then result in their first argument. "announce" displays the second argument as a message on the screen; this is how DIVE shows its messages about seed unlocks and eliminations. "announce" takes three arguments; the third argument is how many milliseconds the announcement lasts for. The other two only take two arguments. "output" converts the second argument to a string and places it as a text element at the bottom of the page, while "console.log" logs the second argument to the console. "output" and "console.log" should only be used for testing/bugfixing, not in a finished mode.

"defaultAbbrevAny" is a typeless version of the other defaultAbbrev operators, which will call one of the four of those depending on the type of the argument (it will leave the argument unchanged if it's not one of the four numeric types).

"@primesUpdate" (2 arguments) updates the array of prime numbers that the Compendium currently has stored so that it contains every prime up to at least the second argument, and then it results in the first argument (so it leaves the CalcArray's running value unchanged).

There's a couple operators that evaluate CalcArrays within a CalcArray. "CalcArray" takes 1 argument, a literal array, and evaluates it as a CalcArray. "CalcArrayParent" takes 2 arguments, where the first is of any type and the second is a literal array, and "applies" the second argument as a CalcArray to the first argument, i.e. evaluates a CalcArray that's the second argument but with the first argument unshifted onto the second argument as its first element. For example, [1, "CalcArrayParent", ["@Literal", "+", 2]] applies the ["+", 2] as if it's a function with 1 as the input, so it evaluates [1, "+", 2], resulting in 3.

DIVE turned out to be a complicated enough mode that it needed an operator added specifically for it: "DIVESeedUnlock", which takes three arguments. "DIVESeedUnlock"'s first argument is a bigint, its second argument is a list of bigints, and its third argument is a number (either 0, 1, 2, 3, or 4). "DIVESeedUnlock" checks the first argument as a new tile being made in DIVE to see if it would unlock a new seed. The second argument is the list of existing seeds to try dividing it by. The resulting value is what new seed would be unlocked (if it returns 1n, that means no unlock, since the first argument can be fully divided by the existing seeds). The third argument is the mode to do the checks in: mode 0 checks the seeds largest to smallest, mode 1 uses the recursive algorithm the original DIVE uses that ensures the minimum possible outcome (but this theoretically runs in exponential, or perhaps factorial, time with respect to the number of seeds, so it could get quite laggy, though you shouldn't run into lag with it in a normal DIVE game), mode 2 checks the seeds smallest to largest, mode 3 checks them in the order they already are in the list, and mode 4 uses the recursive algorithm but to ensure the maximum possible outcome (while still obeying the "can't be divided by any of the seeds anymore" rule) instead of the minimum. There's also "GaussianDIVESeedUnlock", which is like "DIVESeedUnlock" but using GaussianBigInts instead of BigInts; "GaussianDIVESeedUnlock" takes four arguments, with the fourth being a boolean that determines whether the result should be rotated into the first quadrant (if true) or left as is (if false). There's a third one of these, "CustomDIVESeedUnlock", which I will discuss later once we've learned about variables.

Part 3: Other CalcArray Features

Conditionals and Loops

The string "@if" is placed in a CalcArray in the position that an operator would be, but it's not considered an operator itself. "@if" creates an if statement: the next entry of the CalcArray after the "@if" should be a CalcArray that would result in a boolean, and then the terms after that (the first of which should be an operator itself, and continue the CalcArray from there) will only be applied if that boolean CalcArray expression returned true. The if statement lasts until an "@end-if" is reached.

For example, in [3, "@if", ["@This 0, "=", 4], "*", 4, "+", 2, "@end-if", "-", 1], if ["@This 0, "=", 4] results in true (more on what "@This 0" means later), then the *4 and the +2 will be applied, so the CalcArray will result in 13. If ["@This 0, "=", 4] results in false, then the *4 and the +2 will be skipped since they're inside the if statement, but the -1 will still be applied, so the CalcArray will result in 2.

To go along with "@if", there's also "@else" and "@else-if". Each of these works similarly to "@if" in that the terms after them are considered part of their statement until an "@end-else" or "@end-else-if" (respectively) is reached. An "@else" or "@else-if" statement will be skipped unless the most recent "@if" or "@else-if" statement in this CalcArray had its boolean expression return false, and there hasn't been another "@else-if" or "@else" checked since the most recent false-resulting "@if" or "@else-if" check. An "@else" statement does not have a boolean-resulting CalcArray after the "@else": if the most recent "@if" or "@else-if" boolean resulted in false, the "@else" statement is definitely triggered, and the first term after the "@else" should be the operator that starts the "@else" statement. "@else-if", like "@if", does have a boolean CalcArray as the first element after the "@else-if", so for an "@else-if" statement to be applied, the most recent "@if" or "@else-if"'s boolean expression must have returned false, and the "@else-if"'s boolean expression must return true.

To make loops, you use "@repeat". The term after a "@repeat" should be either a number, or a CalcArray that results in a boolean (if it's a number it needs to be a plain number, not a CalcArray that results in a number), and the terms after that is the CalcArray segment to apply repeatedly; the end of a looping segment is denoted by "@end-repeat". If the term after the "@repeat" is a number, then that number is the amount of times that the loop is run. If the term after the "@repeat" is a CalcArray, then that CalcArray will be run before each loop, and the loop will only continue if that CalcArray results in true.

Of course, these conditional and loop statements can be nested inside each other - so make sure you put your "@end-if"s, "@end-repeat"s, etc. in the right places, or things will get buggy! If you need to exit all your statements at once, there's "@end-stack", which marks the end of all ifs, elses, else-ifs, and repeats it's inside at once (though in the repeat case it doesn't forcefully end it, the loop will keep going until it ends as usual). "@end-stack" is currently unused in the Compendium, and I don't foresee it being used anytime soon, so it's currently pretty much untested - so it might not work anyway.

Parents

Strings with an @ at the beginning of them tend to do special things in a CalcArray. These can be special operator-like tools, like conditionals and loops, but they can also be stand-ins for values that will be evaluated when it comes time to evaluate them. "@Parent" strings are one example of this.

An "@Parent" string is a way to reference the running value of the current CalcArray or of one of the parent CalcArrays it's inside. "@Parent -1" will, at evaluation time, be replaced with whatever the running value of the current CalcArray is. "@Parent -2" will be replaced with the running value of the CalcArray that the current CalcArray is inside (if one exists), "@Parent -3" will be replaced with the running value of the CalcArray two layers up, and so on. These work like the indexes in the .at() method for arrays, so while negatives go from the inside out, positives go from the outside in: "@Parent 0" refers to the running value of the outermost CalcArray that this CalcArray is in some nested layer of, "@Parent 1" to one layer within that, and so on. The nonnegative indices are currently unused in the Compendium - the negative indices are much more useful, since their behavior is less dependent on how many layers deep in CalcArrays they're in.

As an example, take [3, "+", 8, "*", [2, "+", "@Parent -2"], "-", 7]. The 3+8 is evaluated first, turning it into [11, "*", [2, "+", "@Parent -2"], "-", 7]. Now the inner CalcArray is evaluated, and the "@Parent -2" refers to the running value of the CalcArray outside the inner one, which in this case is 11, so it becomes [11, "*", [2, "+", 11], "-", 7], which becomes [11, "*", 13, "-", 7], which becomes [143, "-", 7], and thus the result is 137.

Replacing an @Parent string with the appropriate value does not happen until the operator where the @Parent string is an argument is reached in the CalcArray's process. The replacement is not permanent: if this is inside a loop, then the @Parent will be re-evaluated each time it's reached.

Variables

Normally, the only changing value that a CalcArray stores is its "running value" (its current first argument), as well as being able to access the running values of its parent CalcArrays via @Parent strings. But those aren't the only changeable values that a CalcArray can work with: you can also add changeable variables into a CalcArray and work with those.

Variables in a CalcArray are stored in an array that the CalcArray works with internally. By default, this array is empty. The typical way to add variables to a CalcArray is at the start, before the CalcArray begins properly. To do this, begin the CalcArray by having its first few elements be the variables, then put in "@end_vars", and then have the proper CalcArray part from there. For example, the CalcArray [3, "aaa", true, 8, "@end_vars", 4, "+", 5] will have [3, "aaa", true, 8] as its array of variables, then it will evaluate [4, "+", 5] and result in 9.

Of course, variables are useless if you don't access them. To access a variable, use an "@Var" string. For example "@Var 0" becomes the variable at index 0 of the variables array, "@Var 1" becomes the variable at index 1 of the variables array, "@Var -1" becomes the last variable of the variables array, "@Var -2" becomes the second-to-last variable of the variables array, and so on. For example, in [1, 2, 3, 4, "@end_vars", 5, "*", "@Var 2"], the variables array becomes [1, 2, 3, 4], and then it evaluates [5, "*", "@Var 2"]; the variable at index 2 is 3, so this becomes [5, "*", 3] and results in 15. As with @Parent strings, @Var strings are only replaced with a value when it's time to evaluate them, and are re-evaluated on each loop if applicable.

To change the value of a variable once it's been created, use "@edit_var" a special operator with three arguments. The second argument is the index of the variable to edit, the third argument is the value to set that variable to. The first argument becomes the result, so that "@edit_var" just edits the variable without impacting the running value. For example, [1, "@edit_var", 2, 3, ...] sets the variable at index 2 to the value 3, then the 1 continues as the running value.

There are other similar special operators associated with variables:

Normally, the variables array is local to that specific CalcArray, so children or parents of that CalcArray will not have access to that CalcArray's variables. This is often undesired, because often when using "@edit_var" you want to have the variable's new value be based on its current value, which means you need to access the variable's current value inside a child CalcArray. To allow for this, put "@var_retain" at the beginning of a CalcArray (before the list of variables if it has one), which causes that CalcArray to inherit the variables array from its parent (it'll be the same object, so changes to the variables array made in the child will also affect the parent's variables array). For example, [..., "@edit_var", 1, ["@var_retain", "@Var 1", "*", 2], ...] will change the value of the variable at index 1 to double its current value; if the "@var_retain" wasn't there, the child CalcArray wouldn't retain the variables of its parent, so "@Var 1" wouldn't find anything since that child CalcArray would have no variables.

You could instead use "@var_copy", which does something similar but makes a copy of the variables array instead of transferring it outright (so changes the child makes to the variables array won't transfer back to the parent), but I find that usually "@var_retain" is what you want.

If your CalcArray has a lot of nested layers, putting in a bunch of "@var_retain"s can get annoying quickly, so there's a shortcut: if you put "@global_var_retain" at the beginning of a CalcArray, then not only will it retain the variables from its parent, the variable retaining will automatically cascade to all of its children, and all of its childrens' children, and so on. Likewise, there's "@global_var_copy", and there's also "@global_var_none", which stops a global_var cascade coming from its ancestors from applying to that CalcArray or its children.

Finally, there's also the "game variables"; whereas most variable arrays are local to a specific CalcArray, the game variables are a single array that exists across the whole mode (and is typically initialized before the game starts by the code to set up the mode being played) and can be accessed by any CalcArray. Use "@GVar 0", "@GVar 1", "@GVar -1", "@GVar -2" and the like to access their values, and use "@edit_gvar", "@add_gvar", "@insert_gvar", and "@remove_gvar" to alter them. Since game variables are global, there's no need for a "@var_retain" equivalent, but there sort of is one anyway: if you put "@include_gvars" at the beginning of a CalcArray, then the current values of the game variables will be copied into the beginning of the variables list in that CalcArray. "@include_gvars" was added before "@GVar" strings, so it's an outdated feature you probably shouldn't use (just use "@GVar" strings to acces them), but I still had to mention it.

Array Operators with CalcArray Arguments

Remember those five array operators I mentioned earlier as being too complicated to discuss yet? That was because using them requires an understanding of variables, so now I can tell you how they work. Each of these operators has one of its arguments be a CalcArray expression; what that expression does depends on the operator, but in all of these cases, it will be run multiple times. These expressions themselves will have "inputs" that come from the array being operated on (these inputs change on the different runs of the expression), and the way this is accomplished is by adding those inputs as variables at the end of the variables array of that CalcArray expression, so within the expression you use "@Var -1", "@Var -2", etc. to access their values.

Here are the five operators in question:

And now for perhaps the most complicated operator of all: "CustomDIVESeedUnlock", a very complicated version of the DIVESeedUnlock operators that lets you customize how it works, allowing you to use the DIVE seed unlocking algorithm on things that aren't just BigInts or GaussianBigInts, with your own definitions as what counts for things like division. This operator takes a whopping eleven arguments. The first three arguments do the same thing as they do in the other two DIVE seed unlock operators, while the rest of them all represent functions, and thus have you use "@Var -1" and sometimes "@Var -2" to represent the argument(s) to those functions. Here's what the rest of the arguments do:

"CustomDIVESeedUnlock" is currently unused, though I expect I'll find use for it at some point in the future.

Part 4: How CalcArrays are Used in Modes

CalcArray()'s Other Arguments

So far, everything we've discussed has been within the CalcArray, i.e. the primary argument (argument #0, since the JS arguments array is 0-indexed) to the JS CalcArray() function. But that's not the only argument CalcArray() can take (though it is the only required one)!

Arguments #1 and #2 to CalcArray() make it so it's called "on a specific tile": argument #1 is the vertical coordinate of that tile, argument #2 is the horizontal coordinate of that tile. In the Power Compendium's grid, increasing the vertical coordinate moves downwards, increasing the horizontal coordinate moves rightwards. These are both considered 0 by default.

Arguments #3 and #4 establish the direction of movement. Argument #3 is the vertical component of the movement direction, argument #4 is the horizontal component of the movement direction. These are both considered 0 by default.

Argument #5 is an array of additional arguments (it was made to be an array so that if I add any more info later on, I won't have to mess with the order of the arguments). As of now, this array has meanings for up to three arguments: index 0 has the length of the current merge if one is occuring, index 1 has the maximum spaces per move of the current movement direction, and index 2 has the "move type" (I think this is used for something related to automatic moves, though I don't remember what exactly it distinguishes). This argument is [1, Infinity, 0] by default.

Argument #6 is the grid of tiles that is being worked on. The default here is the normal grid; when something else is being used for this argument, it's usually something like the array of next spawning tiles.

Arguments beyond that probably shouldn't be messed with even if you're writing code for the Compendium, unless you're writing a function that's related to the running of CalcArrays themselves or something along those lines, as they're data CalcArrays pass between themselves for recursion purposes and the like. But, for completeness's sake, here's what they do anyway: argument #7 is the array of parent values, argument #8 is the variables array, argument #9 is the "global variable stat" ("@global_var_retain" sets this to 1, "@global_var_copy" sets this to -1, "@global_var_none" sets this to 0), and argument #10 is an argument called "inner" that's usually true; this argument determines whether this actually counts as a child CalcArray (thus adding its running value to the parents chain) or not (if, else, else-if, and repeat do a recursion call but without actually counting as a child CalcArray).

If you're making a 2048 Power Compendium mode, you'll be writing most of your "code" in CalcArrays themselves, so you shouldn't be worrying about calling the CalcArray() function yourself - that's usually left to the "engine", although sometimes certain modifiers (like random goals) do have to deal with this. However, understanding what data a CalcArray tracks will be useful for the rest of this part.

Internal Representations of Tiles

Before I can explain how things like tile display rules and merge rules work, I have to explain what a tile actually is internally. I believe most 2048 variants create a Tile class for this kind of thing, but my mentality is usually "don't make a new class unless you have to". Tiles don't really have a need for methods and such - all that's important to a given tile is its value(s) and its position. As such, in the 2048 Power Compendium, tiles are just arrays, usually of numbers. For example, in 2187, a tile is a two-element array, where the first number is the power of 3 it is and the second number is what that power of 3 is multiplied by. For example of example, 162 is 34 times 2, so in 2187 the 162 tile internally is [4, 2]. Most Page 1 modes follow this pattern (with the base of the power part changed, of course).

Different modes represent tiles in different ways internally. Here are some examples:

Of course, I didn't give every notable example here - there are some other interesting ways tiles are represented, so if you're interested in examining the Compendium's code, you might want to go through some modes and see how they store their tiles.

These first two sections of Part 4 haven't really been about CalcArrays, have they? I included them here because they provide context that's needed for what comes next.

Other Special Strings

@Parent, @Var, and @GVar strings aren't the only special strings that CalcArrays can refer to. Most of the rest of them refer to in-game objects, which is why I've been putting them off until now. Here's a list of them:

Color Expressions

A color expression is an array similar to a CalcArray that represents some color. Instead of the CalcArray() function, these are evaluated via evaluateColor(); the first argument of that function is the color expression, the next two are the vertical and horizontal coordinate, but then the next one is the grid/tile container being used; color expressions don't support detection of movement direction, next tiles, and so on. A plain hex string, like "#ff0000", is a valid color expression, but if you want the color expression to vary based on the current tile or somesuch, you'll need to use one of the arrays. evaluateColor() will convert the color expression into a string that can be used as that color, or gradient, in the HTML/CSS.

The most common color expressions are those that represent single colors. These are five-element arrays: the 0th element is a string saying which color system it's in, the following four are its dimensions in that system. The most common 0th element is "@HSLA", for which the 1st element is the hue (0 is red, 60 is yellow, 120 is green, 180 is cyan, 240 is blue, 300 is magenta, 360 is red again), 2nd element is the saturation (100 is fully saturated, 0 is greyscale), 3rd element is the lightness (100 is white, 0 is black, 50 is the non-tinted color), and 4th element is "alpha"/opaqueness (1 is fully opaque, 0 is invisible transparent). For example, ["@HSLA", 200, 90, 60, 1] would be this color. The other two are "@HSVA" (1st element is hue, 2nd is saturation (100 is pure color, 0 is greyscale), 3rd is value (100 is fully light, 0 is black), 4th is alpha) and "@RGBA" (1st element is red (0 to 255), 2nd is green (0 to 255), 3rd is blue (0 to 255), 4th is alpha (still 0 to 1)). The four latter elements need to result in numbers, meaning they have to be either plain numbers or CalcArray expressions that result in numbers.

Next are the gradient types. A color expression beginning with "@linear-gradient" will result in a linear gradient of colors. Each entry after that should be either a color expression that's a single color, or a number (which places the most recent color at that percent through the gradient). If there's a number right after the "@linear-gradient" (i.e. before any color entries), it sets the angle of the gradient (0 is bottom-to-top, 90 is left-to-right, 180 is top-to-bottom, 270 is right-to-left, and values between multiples of 90 will be some form of diagonal) For example, if you want a gradient that goes from left-to-right, starts at red, goes to yellow 20% of the way through and stays yellow until 45% of the way through, then ends at blue, you'd do ["@linear-gradient", 90, ["@HSLA", 0, 100, 50, 0], 0, ["@HSLA", 60, 100, 50, 0], 20, 45, ["@HSLA", 240, 100, 50, 0], 100], or some variation of such.

The other gradient types are "@radial-gradient" (gradient positions are still from 0 to 100, with 0 being the center and 100 being the edge), "@conic-gradient" (gradient positions are from 0 to 360, 0 is at the top and it goes clockwise from there), and "@repeating-linear-gradient", "@repeating-radial-gradient", and "@repeating-conic-gradient" are versions of the previous three where, if the last color isn't at the end of the gradient, instead of just having the last color last until the end, it jumps back to the first color and repeats the cycle.

"@multi-gradient" results in multiple gradients stacked on top of each other. Each entry after the 0th in a "@multi-gradient" array should itself be a gradient color expression.

"@rotate" has three elements after the starting string, and what it does is take another color expression and rotate its hue by some amount around the color wheel (clockwise, so 90 degrees would rotate reds to chartreuses, yellows to sea greens, blues to rose magentas, etc.). The 1st element is the amount of degrees to rotate by, the 2nd element is a boolean that, if true, also inverts the lightness of the color (lightness becomes 100 - lightness), and the 3rd argument is the color expression to be rotated. If the color expression is a gradient or multi-gradient, all of the colors in it are rotated.

Tile Display Rules

We've covered most of what CalcArrays do themselves now, so it's time to discuss the places in the 2048 Power Compendium mode they're contained within. I'll use 2187 as my primary example, though I'll pull from other modes where it's necessary.

First of all, how are tile displays generated? Here's what the TileTypes array looks like in 2187:

Each entry of TileTypes defines one tile display rule. The 0th entry of a display rule is either an array like [1, 2] that would match a tile, or a CalcArray expression that results in a a boolean. When a tile is looking for what display rule to use, it goes through TileTypes from start to end, stopping once it hits a rule where either the tile array is the same as the 0th entry of the display rule, or running the 0th entry of the display rule as a CalcArray on that tile results in true (if the array is of the "match a tile" type, it may still try to run it as a CalcArray, but since an invalid CalcArray operator just results in the first argument, doing so will end up resulting in a number, and thus not the boolean value true). In the event every display rule fails, it defaults to the last one.

Once a display rule has been decided, the rest of that rule's entries control the tile's display. The 1st entry is the number that'll be displayed on that tile (this can actually be any type, or a CalcArray that results in any type. If it's a numeric type, that type's defaultAbbrev operator will be run on it after the value is calculated), the 2nd entry is the color or gradient of the tile's background (a color expression), and the 3rd entry is the color of the tile's text (a color expression). Tile backgrounds are allowed to be single colors or gradients or multi-gradients, but gradient text is not supported, so tile colors must be single colors.

Some tile types will have additional entries, as seen in the Ratio-Fill modes:

The 4th entry controls the text's shadow effect. This can be written the way CSS does it directly, or as an array of four elements: in the latter case, the first two elements (numbers) control the horizontal and vertical offset of the shadow from the text, the third element (a number) controls the blur strength, and the fourth element (a color) is the color of the shadow. Of course, any of those elements could be CalcArrays (in the case of the fourth it'd be a color expression instead).

Under normal circumstances, a tile's text size is based on how many characters are in the text - for the most part. The "text length" of a tile is treated as either 2 or (the amount of characters * 0.7), whichever is higher, and then larger text lengths mean smaller text sizes (inversely proportional). The 5th and 6th entries of a display rule let you alter this: use a positive number to mean that exact number, use a negative number to mean (the amount of characters * abs(that number)), and then the text length will be treated as whichever of those two is higher. (If one of them is set to 0, it reverts to its default, which is 2 for the 5th entry and -0.7 for the 6th)

Finally, any entries beyond the 6th are addons. There are currently two possible types of addons:

Special Color Schemes

Most modes use color expressions to determine the colors/backgrounds of their tiles, but there's a collection of color schemes that were too complicated to implement via CalcArrays, and thus had to be directly implemented in JavaScript instead. These are the "special color schemes". To use a special color scheme for a tile instead of its normal display, have "@ColorScheme" be the display rule's 2nd entry, then put the name of the special color scheme for the 3rd entry, and the 4th entry should be an array whose 0th entry is the value (usually a BigInt) to input into the special color scheme, and further entries are "parameters" for that special color scheme. Special color schemes can also be used in PrimeImages. Here's an example of what this looks like:

And here's a list of the special color schemes:

Merge Rules

TileTypes is one of the two main places where CalcArrays are used in every mode. The other one is MergeRules, which is in my opinion the most important part of a mode, since it's where the rules of the mode, i.e. what tiles can merge, are. Here's 2187 again:

Like with TileTypes, entries earlier in MergeRules are checked first when tiles collide. But an additional precaution has to be taken here: if, for example, two or three of the same tile can merge, putting the three-tile merge first is necessary, but not sufficient, because the two-tile collision will occur before the three-tile collision does. This is why @NextNE strings exist, as seen in 1296's MergeRules:

["@NextNE -1 0", "!=", "@This 0"] in the third merge rule is there specifically to disallow the two-tile merge if the three-tile merge is coming up.

Modes like Isotopic 256 have effects that occur to individual tiles at the end of a turn. In many cases, this is done by a merge whose length (0th entry) is 0. Length-0 merges do not occur during the moving process of a move; instead, they occur once all the tiles have finished moving and merging (but before new random tile(s) spawn), and they occur to each tile individually. Merge length 0 is the official way to do "merges" that only alter one tile. Merges with a length of 1 are not officially supported, and I'm not sure what the engine would do if you tried to include one. I suspect it'd be like a length 0 merge but it can occur in the middle of a move instead of only at the end, but I don't know.

A few merge rules, like those in XXXX, have ten entries instead of six:

When this is the case, that merge rule can take on multiple lengths. This works by having it start at the given length, then create copies of the rule by incrementing the length, and at each increment it pastes in a new copy of the 1st entry's CalcArray (the 1st entry now becomes a CalcArray containing all of the copies, separated by "&&"s), but with some of the "@Next" strings having their first index increased on each copy (in this context an "@This" string acts as an "@Next 0" string and thus also increments). The 0th entry now instead refers to the minimum merge length that the rule is valid for, the 6th entry is the length that the merge rule as given is, the 7th entry is a list of how much to increment the first index of the "@Next" strings by (Strings that were "@This" in the original are incremented by the 0th entry of the 7th entry, strings that were "@Next 1" in the original are incremented by the 1st entry of the 7th entry, strings that were "@Next 2" in the original are incremented by the 2nd entry of the 7th entry, and so on), the 8th entry is how much to increase the merge length on with each increment, and the 9th entry is either a single number (the maximum allowed merge length) or an array of numbers (only those merge lengths are allowed). A merge rule has to have five, six, or ten entries; the 5th entry isn't necessary on its own, but once you're going for a multi-length merge rule you need to have all four of the multi-length entries. The turning of a multi-length merge rule into multiple copies occurs at the start of the game, so the multi-length merge rule will not remain as a single merge rule once the game has begun.

It is possible for a merge to have more outputs than inputs. This is called a "Merge Overflow" merge, and in this case there's a few special strings you can use to indicate how the overflow behaves. These strings go at the start of the 3rd entry of a merge rule, i.e. at the beginning of the array of output tiles. Here are the merge overflow strings:

Tile Spawns

The variable startTileSpawns is used to define the possible spawning tiles. Each element of this array is itself a two-element array representing one possible spawning tile, where its first element is the tile itself and its second element is the chance of it spawning. These chances do not have to be out of 100%; it simply means that a tile with a larger chance is more likely to spawn than one with a smaller chance. Normally the spawning tiles and chances are written as plain arrays and numbers respectively, though you can put CalcArrays into them if you wish.

If a spawning tile array's first entry is "Box", then the second entry is still the chance, but there are more entries after that. This is used to denote spawns like those in 3072 or 1535 1536 1537, where there's a "box" containing a select amount of select tiles that refills when it runs through them all, ensuring a particular long-term distribution of spawning tiles instead of leaving it up to random chance. After the second entry, the next entry is a spawning tile, then the next entry is how many of that tile are in the box, and repeat.

There's also the forcedSpawns array. Each entry of forcedSpawns is an array consisting of a CalcArray expression, a string, a boolean, and one or more tile arrays. If the CalcArray expression is true this turn, then all of the tiles in this entry will be spawned this turn, either before or after the random spawns depending on the string, which can be "BeforeSpawns" or "AfterSpawns". This is used by the Temporary Holes modifiers.

Scripts

Some modes have "scripts", CalcArrays that aren't associated with any other object, and are instead evaluated on their own at particular times in the game. These are primarily used for random goals, but some modes have other uses for them. For example, here's what 1762's scripts entry looks like when its random goals are in the "Random goals build upon the previous goals." setting:

Each entry of scripts is an array with two elements, which collectively are a "script": the 0th element is the CalcArray to be evaluated, and the 1st element is a string that indicates when this script should be triggered. Though the script's CalcArray has to be a full CalcArray, meaning it has to start with a running value and eventually result in something, the result value of a script isn't used for anything, so scripts are only useful if they modify things outside themselves. Most of the time, this means modifying the game variables. In the 1762 random goals example, "@GVar 0" is being used to store the current random goal, "@GVar 1" is storing how many random goals have been reached so far, and "@GVar 2" is a boolean that's normally false. The first script's job is to set "@GVar 2" to true when a tile equal to the current goal is merged, and the second script's job is to increase the random goal at the end of the turn, including increasing your goals reached count, setting "@GVar 2" back to false, and doubling the random goal then increasing it by -1, 0, or 1 at random (since the tiles reachable from a tile N in 1762 in one merge are 2N-1, 2N, and 2N+1).

The process of evaluating a script is, for the most part, no different than evaluating any other CalcArray. The new part here is that string indicating when to run the script. Here's the list of such strings:

Stat Boxes

The boxes above the grid that keep track of statistics in the game, such as your score or the Discovered Tiles, are also defined via arrays containing CalcArrays and other entries. Each entry of the statBoxes array is a single stat box, and for many modes statBoxes is just [["Score", "@Score"]], meaning the only stat box is the score box. (wondering where it is in the mode definitions for some modes? It's at the top of loadMode(), set before the individual mode defining code, so if a mode doesn't do anything else with the statBoxes then it defaults to that, rather than the default being specified in every mode it applies to). But some modes have more going on with their stat boxes. Here's 2592's:

And here's DIVE's:

When a mode has multiple stat boxes, the ones listed first in statBoxes are the leftmost ones. For those unfamiliar with JavaScript syntax, the arrays of commas with ... before them are used to mean that an amount of arguments equal to the amount of commas are set to their defaults. So that means there's a lot of possible elements for stat box definitions! Only the 0th and 1st elements are required, though. Here's what they all do:

With all those entries put together, it's theoretically possible to make a stat box behave as a button that does something when pushed, though no Compendium mode has done this yet - clickable stat boxes were added mostly for the sake of toggling how Discovered Tiles is displayed.

Other Special Operators

There are some CalcArray operators I didn't mention in the previous parts because they access or modify some of the in-game objects that were introduced here in Part 4, so here's a list of them:

Part 5: Non-CalcArray Details For Injected Modes

Notable JavaScript Variables

We've covered basically everything relevant to CalcArrays at this point, but knowing how to use CalcArrays doesn't mean much if you don't know how to put it all together into an injected mode.

I am aware of two main ways to make an injected mode: by editing the Power Compendium's code directly to make the mode and then exporting it as a save code, or editing an existing save code into a new mode. Let's start with the first one. If you want to make a mode within the 2048 Power Compendium's code, here are some of the JavaScript variables you'll want to consider (with their types in parentheses):

There are plenty more variables the Compendium works with, but ones beyond these are generally meant to be used within the "engine", not by the person making the mode.

Save Codes

The 2048 Power Compendium has a few kinds of save codes, but this blog post is documentation on how to make a mode, so I'll only be going over the main kind of save code, the one that loads either an in-progress game or the start of a mode.

A save code is a text string consisting of a bunch of parts separated by | characters. The first two parts are in plaintext: the first is "@2048PowCompGame" for in-progress games, "@2048PowCompMode" for new games of modes. The second is the version number, which updates whenever some new feature is added to CalcArrays or to the save code format - newer versions of the Compendium can have older save codes loaded, but older versions cannot load newer save codes. So far, every major (first or second version number) update, as well as a few small (third version number) updates have had the save code version increment; there have been a couple of these were nothing outright new was added to CalcArrays or the save code format, but the addition of new special color schemes still forced the save code version increment.

The rest of the parts are in base-64 encoding, either of just a plain string or of the object run through JavaScript's "stringify" function - or, rather, an extended version of stringify that also handles BigInts, BigRationals, and GaussianBigInts via the "@BigInt", "@BigRational", and "@GaussianBigInt" methods described back in Part 2, as well as using "@Infinity", "@-Infinity", and "@undefined" to store the appropriate values since stringify would otherwise turn them into null.

These save codes include several pieces of data not discussed in the last section, since they also store data related to global modifiers. Here's the order of the rest of the parts in a mode save code:

An in-progress game save code includes all of those, and then the following additional pieces:

Conclusion

There's plenty more I could say about the code of the 2048 Power Compendium, but this blog post isn't meant to be a total retrospective on the 2048 Power Compendium's development. This blog post is about documenting the behavior of CalcArrays, and giving users enough information to be able to make their own modes to save as save codes and share with others, and with everything I've covered here, I'm hoping I gave a complete enough picture to enable that. There have already been some people who have learned enough about CalcArrays to make injected modes even without this documentation, though usually with some help from me along the way... so hopefully this documentation will make that learning process easier!

Return to Website Blog Homepage